Repository navigation
feat: add Symfony Runtime for Rapira - #1
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Comment |
|
@Zylius "Testo"? could we use standard Phpunit? |
|
fine by me I would say, once some of the things mentioned are addressed. How it is now, its basically what the plain runtime should be doing. However I will not be able to be this as a base for symfony bundle, as its too barebone, but once again, thats the point of symfony runtime, which this fulfills |
|
Status update: the infrastructure and the test suite are reworked; fixes in Done
Findings pinned by testsEach one is a test with the expected behaviour, marked
Still in progress
|
19b296b to
c6c60c4
Compare
|
Status update: the findings from the review are fixed in Done
Found
Not done
|
|
@FluffyDiscord replying here to your comment on a457931, so it does not get lost with the rebased commits:
Agreed on the split: features belong in a bundle, this package should be the runtime. I looked at FluffyDiscord/rapira-symfony-bundle, and most of what it does — worker events, kernel reboot and debug error pages, warmers, profiling, Doctrine, Sentry, DI configuration — has no place here and should stay there. Where I'd draw the line differently is "thin".
The plan:
What do you think? If this works for you, I'll list the extension points the bundle would need, and we can agree on them before either side changes. |
|
@roxblnfk compare the http worker loop, I cannot use this runtime at all - and I should not. I am overwriting how kernel restarts, I have exception handlers for each situation (out of date now for rapira 0.9.0, or 0.9.1 if that found issue is fixed l, will need to update that to bring code in line, the idea stays), I dispatch events, log things, reset services, warmup stuff, and all of this need specific steps to take/hook into in the worker loop, which plain runtime should not simply offer. Not sure how extension points would be feasible here, not to bloat the runtime, remember, people might not want that bundle, and might want to do all of it themselves |
|
@FluffyDiscord good point about the worker loop, that makes sense. What I'd still like to share is not the loop but the two pieces at its edges, which have no lifecycle at all:
If Your Would that work for you? If not, two independent implementations is fine too. |
|
Great review guys. Thanks for your input. One more thing I don't like about this whole PR is that to run this either the {
"extra": {
"runtime": {
"class": "Rapira\\Symfony\\Runtime"
}
}
} Or you need to specify it while launching the command. When switching around servers for PHP this couples the actual source files (composer.json) to the server used, or it has a cumbersome launch command. http.env entry in rapira config file would really help |
Let me think about this a bit. The runtime override is off the table though, too different. |
6dd2405 to
9ef2bd6
Compare
FluffyDiscord
left a comment
There was a problem hiding this comment.
@roxblnfk except the error renderer, its fine as is I guess. Static calls are against how symfony works, but here whatever, the explore query is spotty I would say and other remarks are just suggestions
9ef2bd6 to
3c3fc8a
Compare
roxblnfk
left a comment
There was a problem hiding this comment.
Follow-ups from two independent reviews of the current state, to be addressed in this PR. Each was checked against the code; the first one also on a live Rapira.
rapira/testing uses testo 🤔 |
|
Testo is OK guys |
0899c51 to
3895c77
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Trusted X-Sendfile handling can expose local paths and produce empty file responses across all runtime modes.
2 open findings
What changed in this PR
Introduces the initial rapira/symfony runtime, integrating Symfony HTTP kernels with Rapira’s Classic, Worker, and Dispatcher execution modes.
Changes:
- Adds mode-specific runners and request/response translation.
- Adds comprehensive feature and live-server acceptance tests.
- Adds package documentation, CI, analysis, and release configuration.
| File | Description |
|---|---|
src/Runtime.php |
Selects the runner for each Rapira mode. |
src/Internal/BootServer.php |
Filters boot-time server variables. |
src/Internal/ClassicRunner.php |
Serves Classic-mode requests. |
src/Internal/DispatcherRequest.php |
Owns converted requests and uploads. |
src/Internal/DispatcherRequestFactory.php |
Converts exchanges into Symfony requests. |
src/Internal/DispatcherRunner.php |
Implements the Dispatcher loop. |
src/Internal/ExchangeResponseEmitter.php |
Writes Symfony responses to exchanges. |
src/Internal/OutputBufferException.php |
Represents buffer-stack corruption. |
src/Internal/ResponseDiscardedException.php |
Represents discarded responses. |
src/Internal/SapiResponse.php |
Sends SAPI responses with buffering. |
src/Internal/UnsentResponseException.php |
Wraps pre-head response failures. |
src/Internal/WorkerRunner.php |
Implements the Worker loop. |
tests/Support/TestKernel.php |
Provides a lifecycle-recording kernel. |
tests/Support/ScriptedExchange.php |
Provides a configurable exchange double. |
tests/Support/ReadCountingStream.php |
Detects unintended file reads. |
tests/Support/PhpProcess.php |
Runs bounded PHP subprocesses. |
tests/Support/ForeignDispatcher.php |
Models a non-HTTP dispatcher. |
tests/Support/FileResponseWithoutInternalState.php |
Models future file-response compatibility. |
tests/Support/FakeRuntimeLifecycle.php |
Resets fake-runtime process state. |
tests/Support/FailurePageRuntime.php |
Tests custom failure responses. |
tests/Support/BootRecordingKernel.php |
Records kernel boot ordering. |
tests/Fixtures/file-response-compatibility.php |
Exercises file-response fallback behavior. |
tests/Feature/WorkerModeTest.php |
Tests Worker lifecycle and failures. |
tests/Feature/RuntimeTest.php |
Tests runtime mode selection. |
tests/Feature/FileResponseCompatibilityTest.php |
Tests future Symfony compatibility. |
tests/Feature/DispatcherResponseTest.php |
Tests Dispatcher response emission. |
tests/Feature/DispatcherRequestTest.php |
Tests Dispatcher request conversion. |
tests/Feature/DispatcherModeTest.php |
Tests Dispatcher lifecycle and failures. |
tests/App/rapira.toml |
Configures the acceptance fixture server. |
tests/App/public/index.php |
Defines the fixture entrypoint. |
tests/App/Kernel.php |
Implements the acceptance fixture kernel. |
tests/Acceptance/WorkerModeTest.php |
Tests live Worker mode. |
tests/Acceptance/Support/ServerRequests.php |
Shares live HTTP assertions. |
tests/Acceptance/DispatcherModeTest.php |
Tests live Dispatcher mode. |
tests/Acceptance/ClassicModeTest.php |
Tests live Classic mode. |
testo.php |
Configures feature and acceptance suites. |
README.md |
Documents installation and runtime behavior. |
composer.json |
Defines package metadata and dependencies. |
composer-dependency-analyser.php |
Configures dependency analysis. |
psalm.xml |
Configures static analysis. |
.php-cs-fixer.dist.php |
Configures code style. |
skills.json |
Configures skill discovery. |
LICENSE.md |
Adds the BSD-3-Clause license. |
CHANGELOG.md |
Initializes the changelog. |
.github/workflows/testing.yml |
Runs the test matrix. |
.github/workflows/security.yml |
Audits dependency security. |
.github/workflows/quality.yml |
Runs style and static checks. |
.github/workflows/create-release.yml |
Automates release creation. |
.github/release-please/version.json |
Initializes release versions. |
.github/release-please/config.json |
Configures release notes and versions. |
.github/ISSUE_TEMPLATE.md |
Adds an issue-report template. |
.github/dependabot.yml |
Configures dependency updates. |
.github/CODEOWNERS |
Assigns repository ownership. |
.gitignore |
Excludes generated artifacts. |
.gitattributes |
Configures archives and line endings. |
.editorconfig |
Defines editor formatting defaults. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Under the Rapira SAPI a worker starts without an output buffer, so ob_flush() inside a StreamedResponse callback raises a notice that Symfony's error handler turns into an exception, and the client gets the first chunk followed by an error page. Under the cli SAPI of the Feature suite the same callback finds a buffer and passes, so only a live server shows it. Worker and Classic modes are skipped as a known bug; Dispatcher mode buffers the callback itself and stays active. The pool runs a single process so the request after the stream reaches the same worker, which the boot id route then confirms. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
chore: require rapira/testing ^0.1.7 The last request went unterminated only because the 0.1.6 double returned false together with it; like the host, 0.1.7 answers true for every served request and false from a separate empty call. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
The protected getMode() and getHttpDispatcher() hooks, the RequestLoop interface pair and the injectable boot $_SERVER of the request factory existed only so tests could replace the host; the Feature suite now scripts the host through the rapira/contract stubs instead, so the Runtime calls the Rapira functions directly and the runners build their own collaborators. The runners are internal and move into Internal\ next to them, which leaves Runtime the only public class. The options docblock no longer lists worker_loop_max, which only FrankenPHP's runner reads. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
The Rapira SAPI starts a request without an output buffer, so ob_flush() in a StreamedResponse callback raised a notice that Symfony turned into an exception, and the client got the first chunk followed by an error page. Both modes now send the response inside a buffer of their own, with the 8 KiB chunk size output_buffering gives php-fpm, and close whatever buffers the body left open. Classic mode went through Symfony's HttpKernelRunner, which knows fastcgi_finish_request() but not rapira_finish_request(), so the client waited for terminate(). It gets a runner of its own that finishes the request before terminating, as Worker mode now does too. Outside Rapira the contract stub answers false and the runner finishes the request the way Symfony's own does, so php-fpm serving the same application needs no separate path. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
The loop kept the last request and response in its own variables while gc_collect_cycles() ran, so every request's graph survived until the next one replaced it. Serving a request now happens in a method of its own whose frame owns them. Terminate still runs after handle_request() returns: the response has been finished by then, and a terminate failure ends the worker instead of reaching the host as a failure of a request it already answered. The host builds $_SERVER from the request alone, so the boot-time variables Dotenv set, APP_ENV and APP_SECRET among them, are still added to every request. Only HTTP_* entries were filtered out of them, so a boot-time HTTPS, QUERY_STRING or REMOTE_ADDR leaked into requests that lacked one; the worker now applies the same filter the dispatcher does. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Such an application went through Symfony's own runners, which run it once and return, so a resident worker exited after its first pass without ever serving a request. Getting the runner now fails with a LogicException that names the mode and the application type; Classic mode still hands it to Symfony. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
chore: drop nyholm/psr7 and symfony/psr-http-message-bridge The exchange went through a PSR-7 request on its way to HttpFoundation, and each hop lost a part of PHP's semantics: cookie values reached the application still URL-encoded, so a cookie Symfony set never read back the same; a repeated cookie name kept the last value where PHP keeps the first; several Cookie headers were joined with a comma; a form whose content type was not lowercase stayed unparsed; and file field names were nested by a parser of their own, without the mangling PHP applies to the fields next to them. The Symfony request is now built directly, with cookies, query, form and multipart names all parsed by parse_str() as in the laravel bridge, and form bodies parsed only for POST, PUT, PATCH and DELETE. The host deletes an upload's spool file once the exchange finalizes, which is before terminate(), so each file used to be copied. It is now renamed instead, which the contract names as the way to keep it, and still removed after terminate(). The raw method travelled in three places, a request attribute and two server keys, for the emitter to tell a wire HEAD from what Symfony's uppercasing getMethod() calls HEAD. The emitter now reads it from the exchange it writes to, and the application sees it where PHP puts it, in REQUEST_METHOD. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
A HEAD request for a BinaryFileResponse was prepared as a GET, so the whole file was read only for the host to drop it; the response is now prepared for the request as it is, which leaves the file unread and still lets deleteFileAfterSend() remove it. A file is handed to the host with Exchange::sendFile(), ranges as an offset and a length, so PHP no longer holds its bytes. A file deleted after sending, a temporary file, a stream wrapper path and a status that sends no file still go through PHP, as does a file the host refuses. Printed output was forwarded one byte at a time. It is now forwarded every 8 KiB and on every ob_flush(); a bare flush() reaches no output handler, so a body that relies on it alone waits for the chunk to fill, as under php-fpm with output_buffering. Output removed with ob_clean() is no longer forwarded, and the head now goes out with the first chunk instead of before the body runs. Header values of null are sent empty instead of reaching the host as null. The range test removed its file before reading back the slice handed to the host, so it could only pass while nothing was handed over. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
A request that could not be converted, a terminal 1xx status, an EventStreamResponse, a header value the host rejects or a body that failed before printing anything propagated out of the loop, so one bad request cost the worker and every exchange queued behind it. Until a response head is written another response can still take its place, so such a failure is now logged through Rapira\log() and answered with a plain-text error, 400 when Symfony classifies the conversion failure as a bad request and 500 otherwise, and the loop goes on; a handled response that failed still terminates. A failure of the kernel, of terminate(), of a response whose head is already out, or of the output buffer stack still propagates, since the worker cannot vouch for its state after them. Each exchange is served in a method of its own, so neither it nor the request outlives the call into the next receive(). Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
… work The README described Classic mode as Symfony's runner, dispatcher uploads as copies, streaming without a chunk size and sendFile() as unused, and told readers to install a branch alias that is no longer needed. It also ruled out native sessions in Dispatcher mode, while Symfony's session listener takes the session id from the request's cookies and sets the cookie on the response, so NativeSessionStorage works there; only a direct session_start() does not. Symfony's Kernel::boot() resolves the REMOTE_ADDR placeholder of trusted_proxies once, from the $_SERVER of the moment, which in a resident worker is the first request's client; the README now warns against it. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK's dispatcher request factory was checked against php -S and the Rapira SAPI source, and the bridge differed from it in several places. A header field named outside [A-Za-z0-9-] reached the server bag, with `.` turned into `_`, and the header bag, which reads `_` as `-`; the host drops such a field in Worker mode, and here X_Forwarded_For could pose as X-Forwarded-For. It is now dropped as well. Cookie values were URL-decoded with `+` as a space, a pair without `=` was skipped and a duplicate was detected before name mangling; PHP decodes cookies raw, keeps a bare name with an empty value and keeps the first of two names that mangle to one key. A form media type was cut only at a space or `;`, not at `,`. Form bodies were parsed only for POST, PUT, PATCH and DELETE, while the contract reads a body by its framing and never by the method name; a url-encoded or multipart body is now parsed by its Content-Type alone, for QUERY, GET or any other method. PHP drops an upload whose name has broken brackets, `u[v` or `a[b]c` say, while it repairs a text field of the same name; the bridge nested it. The server params now carry what the SAPI sets in Worker mode and were missing here: REQUEST_SCHEME, GATEWAY_INTERFACE, SERVER_SOFTWARE, DOCUMENT_URI, and HTTP_CONTENT_TYPE and HTTP_CONTENT_LENGTH next to the CONTENT_* pair. The test that pinned REQUEST_SCHEME as absent was checking that the boot-time value does not leak, so it now expects the request's own scheme. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
What the application printed while handling a request, a var_dump() or a stray echo, went to the SAPI output of the dispatcher process, which reaches no client. It is now captured around handle() and sent ahead of the body, as a SAPI would and as the laravel bridge does; a response that carries a printed prefix drops its Content-Length, which no longer counts the body, and a file keeps its length and goes out without the prefix. The output of a handle() that throws is dropped with its buffers. A buffered body went to the host in one write whatever its size. Above 8 MiB it is now written in slices under a Content-Length of its own, so HTTP/1.1 stays off chunked encoding. A buffered body also no longer carries the response's own Content-Length, which the host would enforce against a body it measures itself. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapira serves an application from the root, with no document root and no deployment in a subfolder, yet the dispatcher request carried SCRIPT_NAME, SCRIPT_FILENAME, PHP_SELF and the rest of the script location from the boot-time $_SERVER. Symfony's base URL detection reads them, so a path starting with /index.php lost that prefix to the base URL. Without them Symfony finds an empty base URL and base path, the whole path becomes the path info, and generated URLs are root-relative. The environment, APP_ENV and APP_SECRET among it, is still carried over; Worker mode keeps the script location, which the host fills there itself. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatcher mode is designed for code that serves exchanges concurrently, and the README did not say that Symfony cannot: a StreamedResponse is captured through process-wide output buffers and the framework keeps request-related state in statics and shared services. It now says so and sends code that needs the superglobals or header() to Worker mode. The request and response sections follow the latest changes: the dropped header names, the script location left out, form and multipart bodies parsed for any method, handler output ahead of the body, and long bodies split into 8 MiB writes. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
…n Linux On Linux, Rapira 0.9.0 corrupts the PHP VM stack around a dispatched exchange, and calling isCancelled() or isFinalized() on it in a method makes the worker segfault on every request (rapira-rs/rapira#201). The host respawns the worker silently and the client gets an empty reply, which is how the Dispatcher acceptance suite stopped coming up on the Linux CI jobs. The bridge no longer calls either. A cancelled exchange is now handled and fails its first write, which ends as a discarded response; the error answer relies on its own record of the head instead of isFinalized(). The test for skipping a cancelled exchange is skipped with its expectation kept, so reverting this commit once the host is fixed restores the check; #2 tracks that. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: boot the kernel before the first request in Worker and Dispatcher modes feat: let an extending runtime answer such a failure through createFailureResponse() A throwable that escapes kernel->handle() leaves the kernel in an unknown state and reached the client as the host's bare error; it now gets a plain 500, and the loop ends so Rapira runs the script afresh instead of serving on with that kernel. An error page is the bundle's call, so the runtime offers one method to override rather than rendering one itself. Rapira counts a worker that ends before its first request as a failed start and one that ends after it as a planned recycle, so the kernel boots before the loop for a broken container to be counted, as roadrunner-bundle does. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: require Rapira 0.9.1 Rapira 0.9.1 fixes the worker crash after isCancelled() and isFinalized() (rapira-rs/rapira#201), so the workaround from c6c60c4 is reverted; 0.9.0 still crashes on x86_64 Linux, hence the new minimum. Closes #2 Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the kernel boots before the loop Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: leave the client's authority out of SERVER_NAME Behind a proxy on a unix socket, REMOTE_ADDR held nothing or the socket path, so trusted_proxies matching on PRIVATE_SUBNETS failed in Dispatcher mode while Worker mode trusted the proxy. The SAPI sets SERVER_NAME from the configured server name, which a dispatcher cannot see; filling it from the authority the client sent passed a client value off as a server one. Same change as rapira/http 0.1.6. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: keep a Dispatcher worker serving after a stream fails past its head fix: keep application variables named like CGI ones in a request's $_SERVER fix: stream a file response through PHP when Symfony's internal state is gone fix: truncate input past PHP's limits instead of failing under the debug handler fix: leave an empty upload input null in Dispatcher mode, as FileBag does After handle_request() returns, the host has already removed the uploads and lifted max_execution_time, so terminate listeners lost the files and could hang the worker. A body that fails after its head leaves the kernel intact, so only a damaged output buffer stack still ends the loop. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
…cher mode test: drop the nesting-level cookie test, which PHP never warns about docs: say that a cut-off Dispatcher response is failed only after terminate Classic and Worker modes log PHP's warning, so a silent truncation hid dropped input from operators. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
3419ee4 to
afa1521
Compare

🔍 What was changed
The first version of
rapira/symfony: a Symfony Runtime (Rapira\Symfony\Runtime) that serves a Symfony HTTP kernel from the stockpublic/index.phpin every Rapira mode. Outside Rapira it behaves as Symfony's own runtime.How it works
rapira_finish_request(), then runsterminate().Rapira\handle_request()from the superglobals;terminate()runs after the client has its answer but inside the request, so uploads andmax_execution_timestill apply.Rapira\Http\Exchangeto a SymfonyRequestand writes the response straight into the exchange, one exchange at a time; files go out throughExchange::sendFile(), streamed bodies are forwarded as they are printed.A failure that escapes the kernel is answered with a plain
500(overridable throughRuntime::createFailureResponse()), logged throughRapira\log(), and ends the loop so Rapira starts a fresh kernel. A kernel that cannot boot fails the worker before it takes a request, so Rapira counts it as a failed start.Why?
Symfony apps on Rapira need a runtime that keeps the kernel resident in Worker and Dispatcher modes and maps Rapira's request and response model onto HttpFoundation without the application changing.
Review notes
REMOTE_ADDRplaceholder intrusted_proxiesresolves at boot, when$_SERVERholds no client, so it trusts no proxy in Worker and Dispatcher modes; the README says to list proxies or usePRIVATE_SUBNETS.Checklist
Documentation
The README covers installation, setup, mode behaviour, failure handling, the Dispatcher request and response mapping, and what Dispatcher mode does not support (superglobals,
header(),EventStreamResponse).